2.1 信息的存储

绝大多数计算机将 8 个 bit 即一个字节(byte)作为最小的可寻址的内存单位。1 一个 Hello, World 程序的生命周期 已经提到过虚拟内存的概念,即程序将内存视为一个非常大的字节数组,每个字节都由一个唯一的数字来表示,这被称为该字节的地址(address)。接下来的几个章节会慢慢介绍操作系统与编译器如何将这个空间划分为可以管理的单元来存放 程序对象,即程序数据、指令与控制信息。

指针 是 C 语言中一个极为重要的特性,提供了引用数据结构的元素的机制。指针具有 类型 两个属性,分别用于表示其指向对象的类型与该对象首字节的地址。但在理解这些内容之前,我们需要先了解信息是以什么形式存储在计算机中的。

十六进制表示法

老生常谈,无需多言。

在 C 语言中,用 0x0X 前缀来标识一个十六进制字面量。

对于较大数值的十进制与十六进制的转换,建议直接交给计算器完成。

字数据大小

每台计算机都有一个 字长(Word Size),用于指明该计算机中指针数据的 标称大小(Nominal Size),即一个指针使用的 bit 数量。由于计算机的虚拟内存使用指针来编址,计算机的字长直接决定了该计算机最重要的一个系统参数——虚拟空间地址的上限。一个字长为 w 位的机器其虚拟地址的范围为 0 ~ 2w1。例如 32 位机器的内存上限为 4 GB;现在广泛使用的 64 位机内存上限则为 16 EB。

64 位机仍然保留着对 32 位 与 16 位程序的兼容(仅限 x86,ARM 未知)。

C 语言规定了基本数据类型在 32 位机与 64 位机上的 典型值(解释见 2.2 整数的表示),如下表所示。然而,部分数据类型的确切字节数依赖于程序是如何被编译的。

Todo

为了避免依赖“典型”大小与不同编译器的差异,使用 C99 引入的确定大小的数据类型是一种更好的方式。这些固定大小的数据类型被定义在头文件 stdint.h 中。例如 intN_tuintN_t 分别定义了固定大小的有符号整数与无符号整数。其中 N 可以为 8、16、32、64。

char 类型而言,虽然大多数编译器将其视为 有符号整数。但 C 标准没有保证这一点。

C 语言规范只规定了各种数据类型数字范围的 下界,但是没有规定上界。例如 在标准中只规定了 int 类型至少占 2 个字节。在设计程序时,为了保证程序的可移植性,需要保证程序对不同数据类型的确切大小不敏感。例如,使用 int 存储一个指针在 32 位机上可以正常工作,但在 64 位机上会造成问题。

寻址与字节顺序

对于跨越多字节的程序对象,需要明确这个对象的地址与其字节在内存中的排列顺序。在绝大多机器中,对象被存储为 连续的字节序列,其中地址最低的字节则被规定为该对象的地址。

然而,对于每个字节具体的排列顺序,目前存在两种规则:

现代主流处理器,无论是 x86_64 还是 ARM 使用的都是小端法;主流操作系统同样基于小端法运行。然而诸如 ARM、MIPS 处理器其使用 双端法,同时兼容小端法与大端法,具体使用哪种方法需要操作系统自行配置。不过对于应用来说,机器具体使用哪种方法是完全不可见的,处理器会自动将字节排列处理成对应的实际数据。

然而,当需要直接阅读由 反汇编器(Disassembler) 生成的程序的机器指令时,了解机器使用了何种排列方式是很重要的。3 程序的机器级表示 会详细介绍相关内容。

与此同时,当我们编写一些规避正常类型系统的程序,例如含有 强制类型转换(cast)联合(union) 的 C 程序时,了解这些内容是必须的。大多数应用编程都强烈不推荐这种编程技巧,但其在系统级编程中却很有用。

字符串

C 语言默认使用 ASCII 编码,使用 ASCII 编码的机器对相同的字符串具有相同的字节序列,无论该机器使用大端法还是小端法。C 语言中的字符串总是以 NUL(值为 0x00)字符来指示结尾;此外,ASCII 编码中十进制 n 的 ASCII 编码恰好是 0x3n

然而,ASCII 能够编码的字符数十分有限。为了兼容更多语言文字的编码,提出了 Unicode 标准。Java 语言就默认使用 Unicode 编码。其使用 4 byte 来表示一个字符,但并非所有字符均使用 4 byte。常用字符使用的 byte 少于不常用的字符。例如 UTF-8 标准将每个字符编码为一个字节序列, 其中保持了与 ASCII 编码的兼容,即标准 ASCII 字符在 UTF-8 中仍具有相同的字节序列。

代码

由于不同的机器使用不相兼容的指令集与编码方式,不同的操作系统也具有不同的编码规则,因此在某个机器与操作系统上编译出的二进制程序很少能在其它操作系统或机器上运行。

此外,从机器的角度来看,编译出的二进制程序也仅仅只是字节序列,程序中不会保留原始程序的诸如变量名等任何信息,除非编译时向程序中添加了用于调试的辅助表等信息。

布尔代数与运算

布尔代数(Boolean Algebra)提出了一套研究逻辑值 TrueFalse 的规则。最简单的布尔代数定义在集合 {0,1} 之上,包含与、或、非、异或四种基本运算。

不过我们可以将其拓展到 位向量,即长度为 w 的由 01 构成的串之上。位向量的运算即为其位级表示中对应每一位间的运算。显然对于 w>0,有 2w 个各异的长度为 w 的位向量。定义在这些位向量上的位级运算构成了一个 布尔环(Boolean Ring),其与定义在整数集上的运算与欧许多相似之处。例如与整数集上每一个整数都具有一个加法逆元类似,布尔环中的每个元素都具有一个异或逆元,该逆元恰好为元素自身。

位向量可以用于编码有限集合中的元素。例如第八章中,我们使用位向量掩码表示向程序中发送的各种信号。

C 语言支持多种位级运算。例如

这些运算可以在各种整形数据上。其中一个常见的用法是实现 掩码 运算。掩码是指从一个字中选出的位的集合。例如运算 x&0xFF 代表取出变量 x 中低八位的位级表示;~0 可以生成一个全 1 的掩码,且比直接用 0xFFFFFFFF 表示更具有可移植性。

确定一个位级表达式最好的方法就是先将十六进制转为二进制,得出二进制结果后再转回十六进制。

C 语言还提供了移位运算,可以将位向量的二进制表示向左或向右移动一定位数。<< 表示左移,>> 表示右移。移位有两种方式:逻辑移位算术移位;二者在左移时行为相同;但在右移时,逻辑移位在左边补 0,而算术移位在最高位补 最高有效位 的值。在 2.3 整数的运算 中会讲解这种行为十分适合用于对有符号数进行操作。

如果移位的位数很大

假如我们对一个长度为 w 位的整型数据移位 kw 位,在许多机器上其等效于 kmodw 位。不过 C 语言标准并没有规定这种行为,所以写程序时应当保持 k<w。Java 语言规定了这一点。

C 语言没有规定对于有符号数使用何种右移方式,但几乎所有编译器与机器都使用算术右移。而对无符号数而言,规定使用逻辑右移。Java 中规定 >> 代表算术右移,而 >>> 代表逻辑右移。

移位运算的优先级

在 C 语言中,移位符号的优先级要 低于 算术加减法的优先级!!!

搞错优先级是一种十分常见的引发程序错误的原因。如果不清楚某个表达式中运算符的执行顺序时,请加上括号!!!

逻辑运算

C 语言提供了一组逻辑运算符 ||&&!,分别对应于命题逻辑中的逻辑或、逻辑与与逻辑非。与位级运算不同的是,逻辑运算认为所有非 0 元素都表示 TRUE,只有 0 (及 NULnullptr)代表 FALSE。逻辑运算表达式同样也只会返回 01

此外,逻辑运算符存在 短路机制,只要对某个子表达式求值时已经能够确定整个表达式的结果,后续的表达式都不会被执行。因此,表达式 (1 + 1 == 2) || (1 / 0) 不会引发错误。